#!/usr/bin/env python
# -*- coding: utf-8 -*-

import getopt
import hashlib
import logging
import os
import re
import socket
import sys

import yaml
from dingtalkchatbot.chatbot import DingtalkChatbot

VERSION = 'log-monitoring-1.0'
DATA_PATH = 'data'
PROJECT = "test"
logging.basicConfig(
    filename="info.log",
    format="%(asctime)s-%(filename)s-%(levelname)s[line:%(lineno)d] %(message)s",
    level=logging.INFO
)
# 设置最大只能读10MB
HOSTNAME = socket.gethostname()
IP = socket.gethostbyname(HOSTNAME)
JIAOBEN_PATH = os.path.realpath(__file__)
YAML_PATH = os.path.join(os.path.dirname(JIAOBEN_PATH), "check_log.yml")


def read_conf(conf_file):
    """
    读取配置文件
    :param conf_file: 配置文件路径(str)
    :return:
    """
    try:
        with open(conf_file, "r", encoding="utf-8") as f:
            yaml_ = yaml.full_load(f)
    except Exception as e:
        logging.error("读取配置文件{}失败:{}".format(conf_file, e))
        raise
    return yaml_


def read_fileline(file, seek_head=0, seek_end=-1):
    """
    获取文件行数

    :param file: 文件路径(str)
    :param seek_head: 头偏移量(int)
    :param seek_end: 尾偏移量(int)
    :return: 行数(int)
    """
    line_num = 0
    with open(file, 'rb') as log_obj:
        log_obj.seek(seek_head, 0)
        one_read = 1024 * 1024 * 8
        now_seek = 0
        while True:
            # 一次读取8m
            if seek_end != -1 and now_seek + one_read >= seek_end:
                buffer = log_obj.read(seek_end - now_seek)
            else:
                buffer = log_obj.read(one_read)
            now_seek = log_obj.tell()
            if not buffer:
                break
            # 获取行数
            line_num += buffer.count(b'\n')
    return line_num


def read_log_file(file, seek_head=0, first_read_size=10 * 1024 * 1024, encoding_='utf8'):
    """
    读取日志文件

    :param file: 文件路径(str)
    :param seek_head: 头偏移量(int)
    :param first_read_size: 第一次时读取文件数据量(int)
    :param encoding_: 文件编码
    :return: 文件内容列表(list),文件偏移量(int)
    """
    try:
        with open(file, 'r', encoding=encoding_) as log_obj:
            log_obj.seek(0, 2)
            log_seek_max = log_obj.tell()
            if seek_head > log_seek_max or seek_head == 0:
                seek_head = log_seek_max - first_read_size
                logging.info("{}偏移量超出,从头开始读取日志文件".format(file))
                if seek_head < 0:
                    seek_head = 0
            log_obj.seek(seek_head, 0)
            msg_lst = log_obj.readlines()
            log_seek_new = log_obj.tell()
    except UnboundLocalError as error_:
        logging.error("{} 日志文件\n{} ".format(file, error_))
        raise
    except FileNotFoundError as error_:
        logging.error("{} 不存在\n{}".format(file, error_))
        raise
    except IsADirectoryError as error_:
        logging.error("{} 是目录\n{} ".format(file, error_))
        raise
    except UnicodeDecodeError as error_:
        logging.error("{} 无效编码\n{}".format(file, error_))
        raise
    except Exception as error_:
        logging.error("{} 其他错误\n{} ".format(file, error_))
        raise
    return msg_lst, log_seek_new


class LogObj:
    FIRST_READ_SIZE_DEFAULT = 8 * 1024 * 1024

    def __init__(self, log_conf, default_regular):
        """

        :param log_conf: 配置信息(dict)
        :param default_regular: 默认正则规则(dict)
        """
        self.paths = log_conf['paths']
        self.encoding = log_conf['encoding']
        type_ = log_conf['type']
        if type_ == 'custom':
            self.error_regular = log_conf['error_regular']
        else:
            self.error_regular = default_regular['error_regular'][type_]
        head_type = log_conf['head_type']
        if head_type == 'custom':
            self.head_regular = log_conf['head_regular']
        else:
            self.head_regular = default_regular['head_regular'][head_type]
        if log_conf['first_read_size'][-1] == 'G':
            self.first_read_size = int(log_conf['first_read_size'][0:-1]) * 1024 * 1024 * 1024
        elif log_conf['first_read_size'][-1] == 'M':
            self.first_read_size = int(log_conf['first_read_size'][0:-1]) * 1024 * 1024
        elif log_conf['first_read_size'][-1] == 'K':
            self.first_read_size = int(log_conf['first_read_size'][0:-1]) * 1024
        else:
            self.first_read_size = LogObj.FIRST_READ_SIZE_DEFAULT

    def main(self, xiaoding, path, project):
        file_data = FileData(path, DATA_PATH)
        log_seek = file_data.read_seek()
        log_msg_lst, log_seek_new = read_log_file(
            path,
            seek_head=log_seek,
            first_read_size=self.first_read_size,
            encoding_=self.encoding
        )
        file_data.write_seek(log_seek_new)
        log_line_num = file_data.read_line_num()
        if log_line_num is None or log_seek == 0:
            logging.info("{}偏移量超出,重新获取行数".format(path))
            log_line_num = read_fileline(file=path, seek_end=log_seek)
        # 复制行数到文件
        log_msg_line = log_msg_lst.__len__()
        log_line_num_new = int(log_line_num) + log_msg_line
        file_data.write_line_num(log_line_num_new)
        line_dct = self.analysis_log(log_msg_lst)
        logging.debug(path + str(line_dct))
        if line_dct != dict():
            dingding_msg = "日志监控\n" \
                           "脚本路径:\n{}\n" \
                           "项目:{}\n" \
                           "主机:{}\n" \
                           "ip地址:{}\n" \
                           "日志路径:\n{}\n" \
                           "报错内容:\n".format(JIAOBEN_PATH, project, HOSTNAME, IP, path)
            for k, _ in sorted(line_dct.items(), key=lambda kv: max(kv[1]['line']))[-10:]:
                if not line_dct[k].get('error_lines', None):
                    continue
                if line_dct[k]['error_lines'][0] is None:
                    dingding_msg = "{} 日志头行匹配失败\n匹配规则:\n{}".format(dingding_msg, self.head_regular)
                    logging.error(dingding_msg)
                    break
                lines_start = line_dct[k]['error_lines'][0]
                lines_end = line_dct[k]['error_lines'][1]
                if lines_end - lines_start < 20:
                    msg_single = ''.join(log_msg_lst[lines_start:lines_end + 1])
                else:
                    msg_single = "{}................\n".format(''.join(log_msg_lst[lines_start:lines_start + 30]))
                dingding_msg = "{} 日志开始行:{}\n {} \n".format(
                    dingding_msg,
                    ','.join([str(i + log_line_num + 1) for i in sorted(line_dct[k]['line'])]),
                    msg_single
                )
            result = xiaoding.send_text(msg=dingding_msg)
            if result.get('errcode') == 0:
                logging.info("{},{} 发送成功".format(str(result), path))
            else:
                logging.info("{},{} 发送失败,报错日志\n{}".format(str(result), path, dingding_msg))
        else:
            logging.info("{} 日志正常".format(path))

    def analysis_log(self, msg_lst):
        """
        处理日志

        :param msg_lst: 日志信息(str)
        :return: 错误日志信息(dict)
        """
        # 匹配错误日志
        line_dct = dict()
        log_msg_lst_len = len(msg_lst)
        reg_err = re.compile(self.error_regular)
        for i in range(log_msg_lst_len):
            if type(reg_err.match(msg_lst[i])) == re.Match:
                log_msg_md5 = hashlib.md5()
                log_msg_md5.update(bytes(msg_lst[i], encoding="utf8"))
                log_msg_md5_hex = log_msg_md5.hexdigest()
                if log_msg_md5_hex in line_dct:
                    line_dct[log_msg_md5_hex]['line'] |= {i}
                else:
                    line_dct[log_msg_md5_hex] = dict()
                    line_dct[log_msg_md5_hex]['line'] = {i}
        # 匹配头实现多行日志合并
        end_line = 0
        a = None
        reg_head = re.compile(self.head_regular)
        for k, _ in sorted(line_dct.items(), key=lambda kv: max(kv[1]['line'])):
            i = max(line_dct[k]['line'])
            if i < end_line:
                continue
            # 错误日志行向下匹配
            for a in range(i, log_msg_lst_len):
                if type(re.match(self.head_regular, msg_lst[a])) == re.Match:
                    end_line = a
                    break
            else:
                end_line = a
            # 错误日志行向上匹配
            for a in range(i, -1, -1):
                if type(reg_head.match(msg_lst[a])) == re.Match:
                    head_line = a
                    break
            else:
                head_line = None
            line_dct[k]['error_lines'] = (head_line, end_line)
        return line_dct


class FileData:
    """
    记录偏移量，行数
    """

    def __init__(self, file, data_path=DATA_PATH):
        """
        :param file: 文件路径
        :param data_path: 数据文件存放路径
        """
        log_path_md5 = hashlib.md5()
        log_path_md5.update(bytes(file, encoding="utf8"))
        log_path_md5_hex = log_path_md5.hexdigest()
        data_log_path = '{}/{}/{}'.format(
            data_path,
            log_path_md5_hex[0:2],
            log_path_md5_hex[2:]
        )
        if not os.path.exists(data_log_path):
            os.makedirs(data_log_path)
        data_log_path_prefix = '{}/{}'.format(
            data_log_path,
            os.path.basename(file)
        )
        self.log_seek_path = '{}.pos'.format(data_log_path_prefix)
        self.log_line_num_path = '{}.num'.format(data_log_path_prefix)

    def read_seek(self):
        """
        读取记录偏移量

        :return: 偏移量(int)
        """
        if os.path.exists(self.log_seek_path):
            try:
                with open(self.log_seek_path, 'r') as log_seek_obj:
                    log_seek = log_seek_obj.read()
            except Exception as error_:
                logging.error("读取偏移量文件失败\n{}".format(error_))
                raise
            try:
                log_seek = int(log_seek)
            except ValueError:
                logging.info("偏移量数据转换整数失败,重置为0")
                log_seek = 0
        else:
            log_seek = 0
        return log_seek

    def read_line_num(self):
        """
        读取记录行数

        :return: 行数(int)
        """
        if os.path.exists(self.log_line_num_path):
            try:
                with open(self.log_line_num_path, 'r') as log_line_num_obj:
                    log_line_num = log_line_num_obj.read()
            except Exception as error_:
                logging.error("读取日志行数文件失败\n{}".format(error_))
                raise
            try:
                log_line_num = int(log_line_num)
            except ValueError:
                logging.info("日志行数数据转换整数失败,重置为None")
                log_line_num = None
        else:
            log_line_num = None
        return log_line_num

    def write_seek(self, seek):
        """
        偏移量记录到文件

        :param seek: 偏移量(int)
        :return: None
        """
        if type(seek) != int:
            raise TypeError('write() argument must be int, not {}'.format(type(seek).__name__))
        try:
            with open(self.log_seek_path, 'w') as log_seek_obj:
                log_seek_obj.write(str(seek))
        except Exception:
            logging.error("写入偏移量记录文件失败")
            raise

    def write_line_num(self, line_num):
        """
        行数记录到文件

        :param line_num: 行数(int)
        :return: None
        """
        if type(line_num) != int:
            raise TypeError('write() argument must be int, not {}'.format(type(line_num).__name__))
        try:
            with open(self.log_line_num_path, 'w') as log_line_num_obj:
                log_line_num_obj.write(str(line_num))
        except Exception:
            logging.error("写入日志行数文件失败")
            raise


def main(argv):
    """
    -v,--version        查看版本信息
    -h,--help           查看使用帮助
    -f                  指定配置文件
    --project           设置默认项目名
    """
    project = PROJECT
    conf_path = YAML_PATH
    opts, args = getopt.getopt(
        argv[1:], 'hvf:',
        ['help', 'version', 'project=']
    )
    try:
        for opt, value in opts:
            if opt in ('-h', '--help'):
                print(main.__doc__)
                exit(0)
            if opt in ('-v', '--version'):
                print(VERSION)
                exit(0)
            if opt in ('--project',):
                project = value
            if opt in ('-f',):
                conf_path = value
    except getopt.GetoptError as error_:
        print(error_)
        print(main.__doc__)
        exit(1)
    yaml_ = read_conf(conf_path)
    xiaoding = DingtalkChatbot(yaml_['dingtalk_webhook'], secret=yaml_['dingtalk_secret'])
    for obj in yaml_['inputs']:
        log_obj = LogObj(obj, yaml_['default_regular'])
        for path in log_obj.paths:
            log_obj.main(xiaoding, path, project)


if __name__ == '__main__':
    main(sys.argv)
